CloudFront + Lambda 関数 URL 構成でPOST/PUT リクエストを行うために Lambda@Edge でSigv4署名する
こんにちは。リテールアプリ共創部のきんじょーです。
2024年4月に、CloudFront が Lambda 関数 URL をオリジンとした OAC のサポートが開始されました。
このアップデート以前は、Lambda 関数 URL を IAM 認証で保護した場合、CloudFront から Lambda 関数 URL を実行するために、Lambda@Edge を使ってリクエストに Sigv4 の署名をする必要がありました。
OAC 対応により、マネージドで署名を付与してくれるようになり、CloudFront を前段に置いた Lambda 関数 URL の実装が容易になりました。
しかし、この OAC は 全ての HTTP メソッドには対応しておらず、POST/PUT リクエストを行う場合は、依然 Sigv4 で署名が必要です。
自前で Sigv4 の署名をする Lambda@Edge を実装する機会があったので、備忘のために残しておきます。
全量のコードは以下のリポジトリに格納してあります。
お手元で試したい方はクローンしてデプロイしてみて下さい。
やってみる
AWS 構成
生成 AI を利用した API を公開する際、タイムアウトのクォータの問題で API Gateway を使わずにLambda 関数 URL を利用するケースがあります。
今回はそのエンドポイントで Slack アプリの Webhook を受ける構成をとっていたため、POST リクエストを捌く必要がありました。
※この記事では Sigv4 の署名を趣旨としているため、WAF や DynamoDB、Bedrock といったリソースの説明・実装は割愛します。
Lambda@Edge の実行タイミング
Lambda@Edge は CloudFront 4 つのイベントをトリガーに実行できます。
- Viewer リクエスト
- Origin リクエスト
- Origin レスポンス
- Viewer レスポンス
今回は CloudFront からオリジンにアクセスする際に、リクエストに署名を付与するため、Origin リクエストをトリガーに Lambda@Edge を実行します。
署名処理の実装
以下のコードでオリジンリクエストをインターセプトして、署名をヘッダーに付与します。
x-forwarded-for ヘッダーを署名に含めてしまうと、 Lambda 関数にリクエストが届く際に CloudFront によって書き換えられてしまう可能性があり、署名するヘッダーから除外が必要でした。
また、署名時は base64 エンコードされた body をデコードする必要があり、この 2 点で大きくハマりました。
import {
CloudFrontRequestEvent,
CloudFrontRequestHandler,
CloudFrontResponseCallback,
} from "aws-lambda";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { parseUrl } from "@aws-sdk/url-parser";
import { SignatureV4 } from "@aws-sdk/signature-v4";
import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { Sha256 } from "@aws-crypto/sha256-js";
export const handler: CloudFrontRequestHandler = async (
event: CloudFrontRequestEvent,
_context
) => {
const request = event.Records[0].cf.request;
const url = `https://${request.headers.host[0].value}${request.uri}`;
const body = request.body?.data || "";
// 署名時はbase64エンコードされたbodyをデコードする
const decodedBody = Buffer.from(body, "base64").toString("utf-8");
const parsedUrl = parseUrl(url);
const httpRequest = new HttpRequest({
headers: {
host: parsedUrl.hostname || "",
...Object.fromEntries(
Object.entries(request.headers)
.filter(([k, v]) => k.toLowerCase() !== "x-forwarded-for") // x-forwarded-forはLambdaに到達するまでにCloudFrontに書き換えられる可能性があり、署名には含めない
.map(([k, v]) => [k.toLowerCase(), v[0].value])
),
},
hostname: parsedUrl.hostname || "",
method: request.method,
path: parsedUrl.path,
body: decodedBody,
});
const signer = new SignatureV4({
credentials: defaultProvider(),
region: "ap-northeast-1",
service: "lambda",
sha256: Sha256,
});
const signedRequest = await signer.sign(httpRequest);
// 署名されたヘッダーをCloudFrontリクエストに追加
for (const key of [
"authorization",
"x-amz-date",
"x-amz-security-token",
"x-amz-content-sha256",
]) {
request.headers[key] = [{ key: key, value: signedRequest.headers[key] }];
}
return request;
};
Lambda@Edge の CDK 実装
Lambda@Edge に使用する Lambda は CloudFront のコントロールプレーンが配置されているバージニア(us-east-1)リージョンにデプロイする必要があります。
CloudFront やその他リソースは東京リージョン(ap-northeast1)にデプロイしたい場合、そのままではクロスリージョンで Lambda@Edge に設定する Lambda の ARN を取得できません。
東京リージョンにデプロイするスタックから参照できるように、SSM パラメーターストアに Lambda@Edge で使用する Lambda の ARN を格納しておきます。
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
export class LambdaEdgeStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
const lambdaEdgeFunction = new NodejsFunction(this, "LambdaEdgeFunction", {
runtime: lambda.Runtime.NODEJS_20_X,
entry: "./lib/lambda-edge/origin-request-sigv4-signer.ts",
handler: "handler",
memorySize: 1769,
timeout: cdk.Duration.seconds(5),
role: new iam.Role(this, "LambdaEdgeFunctionRole", {
assumedBy: new iam.CompositePrincipal(
new iam.ServicePrincipal("lambda.amazonaws.com"),
new iam.ServicePrincipal("edgelambda.amazonaws.com")
),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
),
iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambda_FullAccess"),
],
}),
});
new ssm.StringParameter(this, `${id}-Origin-Request-Sigv4-Signer-Fn-Id`, {
description: "The Lambda@Edge ARN for CloudFront",
parameterName: "/LambdaFunctionUrlsSigv4SignerSample/LambdaEdgeArn",
stringValue: lambdaEdgeFunction.currentVersion.functionArn,
tier: ssm.ParameterTier.STANDARD,
});
}
}
Lambda と CloudFront の CDK 実装
CloudFront の後段の Lambda はインラインで「Hello from Lambda!」の 200 応答を返す仮実装です。自前で署名するため、CloudFront には OAI の設定は不要です。
先ほど格納した Lambda@Edge の ARN はカスタムリソースで SSM パラメーターから値を取り出しています。
import * as lambdaPython from "@aws-cdk/aws-lambda-python-alpha";
import * as cdk from "aws-cdk-lib";
import {
AllowedMethods,
CachePolicy,
Distribution,
HttpVersion,
LambdaEdgeEventType,
OriginRequestPolicy,
PriceClass,
ResponseHeadersPolicy,
ViewerProtocolPolicy,
} from "aws-cdk-lib/aws-cloudfront";
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
import {
AwsCustomResource,
AwsCustomResourcePolicy,
PhysicalResourceId,
} from "aws-cdk-lib/custom-resources";
import { Construct } from "constructs";
export class ServerStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
/**
* Lambda関数
*/
const lambdaFunction = new cdk.aws_lambda.Function(this, "Lambda", {
code: cdk.aws_lambda.Code.fromInline(`
def handler(_event, _context):
return {
'statusCode': 200,
'body': 'Hello from Lambda!'
}
`),
handler: "index.handler",
runtime: cdk.aws_lambda.Runtime.PYTHON_3_12,
architecture: cdk.aws_lambda.Architecture.ARM_64,
memorySize: 1769,
timeout: cdk.Duration.minutes(15),
});
const lambdaFunctionUrl = lambdaFunction.addFunctionUrl({
authType: cdk.aws_lambda.FunctionUrlAuthType.AWS_IAM,
cors: {
allowedMethods: [cdk.aws_lambda.HttpMethod.ALL],
allowedOrigins: ["*"],
},
});
new cdk.CfnOutput(this, "LambdaFunctionUrl", {
value: lambdaFunctionUrl.url,
});
/**
* CloudFront
*/
const cloudFrontDistribution = new Distribution(this, "Default", {
defaultBehavior: {
origin: new cdk.aws_cloudfront_origins.FunctionUrlOrigin(
lambdaFunctionUrl
),
allowedMethods: AllowedMethods.ALLOW_ALL,
cachePolicy: CachePolicy.CACHING_DISABLED,
originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
responseHeadersPolicy: ResponseHeadersPolicy.SECURITY_HEADERS,
edgeLambdas: [
{
eventType: LambdaEdgeEventType.ORIGIN_REQUEST,
functionVersion: cdk.aws_lambda.Version.fromVersionArn(
this,
"OriginRequestSigv4SignerFn",
this.getLambdaEdgeArn(
"/LambdaFunctionUrlsSigv4SignerSample/LambdaEdgeArn"
)
),
includeBody: true,
},
],
},
httpVersion: HttpVersion.HTTP2_AND_3,
priceClass: PriceClass.PRICE_CLASS_200,
});
lambdaFunction.addPermission("AllowCloudFrontServicePrincipal", {
principal: new cdk.aws_iam.ServicePrincipal("cloudfront.amazonaws.com"),
action: "lambda:InvokeFunction",
sourceArn: `arn:aws:cloudfront::${
cdk.Stack.of(this).account
}:distribution/${cloudFrontDistribution.distributionId}`,
});
new cdk.CfnOutput(this, "CloudFrontDistributionUrl", {
value: `https://${cloudFrontDistribution.distributionDomainName}`,
});
}
getLambdaEdgeArn(lambdaArnParamKey: string): string {
const lambdaEdgeArnParameter = new AwsCustomResource(
this,
"LambdaEdgeCustomResource",
{
policy: AwsCustomResourcePolicy.fromStatements([
new PolicyStatement({
effect: Effect.ALLOW,
actions: ["ssm:GetParameter*"],
resources: [
this.formatArn({
service: "ssm",
region: "us-east-1",
resource: "*",
}),
],
}),
]),
onUpdate: {
service: "SSM",
action: "getParameter",
parameters: { Name: lambdaArnParamKey },
physicalResourceId: PhysicalResourceId.of(
`PhysicalResourceId-${Date.now()}`
),
region: "us-east-1",
},
}
);
return lambdaEdgeArnParameter.getResponseField("Parameter.Value");
}
}
動作検証
CloudFront 経由で実行した場合
IAM 認証をかけた Lambda 関数 URL に対して、CloudFront 経由でリクエストが可能になりました!
# BodyなしでPOSTリクエスト
% curl -X POST https://dc1isj65gc48f.cloudfront.net
-> Hello from Lambda!
# BodyありでPOSTリクエスト
% curl -X POST https://dc1isj65gc48f.cloudfront.net \
-H "Content-Type: application/json" \
-d '{"test":"test"}'
-> Hello from Lambda!
Lambda 関数 URL を直接実行した場合
CloudFront を経由しないと Lambda@Edge で署名されないため、IAM 認証でエラーが返ります。
% curl -X POST https://z3dtedgarwwyjccwd3oqhtnove0gevdy.lambda-url.ap-northeast-1.on.aws
-> {"Message":"Forbidden"}
CloudFront + Lambda 関数 URL でも 自前で署名すれば POST/PUT リクエストが可能です
一度実装してしまえば何てことはないですが、SigV4 の署名検証エラーのデバッグにハマり、かなりの時間を費やしてしまいました。
CloudFront + Lambda 関数 URL で POST/PUT リクエストを受け付ける必要がある場合、このブログを思い出していただけると幸いです。
以上。リテールアプリ共創部のきんじょーでした。
参考
参考にさせていただきました。